<?php

namespace Gnway\Ticket\Rest\Contracts;

use Gnway\Ticket\Rest\Config\DispatcherConfig;
use Gnway\Ticket\Rest\Container\RestContainer;
use Gnway\Ticket\Rest\Exceptions\BadUrlException;
use Gnway\Ticket\Rest\Exceptions\ClassMethodNoDefException;
use Gnway\Ticket\Rest\Exceptions\ClassNoDefException;
use Gnway\Ticket\Rest\Exceptions\RouteBoundParameterException;
use Gnway\Ticket\Rest\Exceptions\URLNotFoundException;
use Gnway\Ticket\Rest\Facades\Request;
use Gnway\Ticket\Rest\Facades\Property;
use Gnway\Ticket\Rest\Facades\Route;
use Gnway\Ticket\Rest\Mapping\RouterMapping;
use Gnway\Ticket\Rest\Request\RequestContext;
use Gnway\Ticket\Rest\Response\ResponseFactory;
use Gnway\Ticket\Rest\Util\ClassUtils;
use Gnway\Ticket\Rest\Util\FunctionUtils;
use Gnway\Ticket\Rest\Util\MethodUtils;
use JsonSerializable, Closure;
use ReflectionException, RuntimeException, Exception;

abstract class AbstractFrontRequestDispatcher
{

  /**
   * Route mapping target
   *
   * @var mixed
   */
  protected $target = NULL;

  /**
   * Api response.
   *
   * @var mixed
   */
  protected $response = NULL;

  /**
   * Dispatcher request context
   *
   * @var RequestContext
   */
  protected RequestContext $context;

  /**
   * Dispatcher config
   *
   * @var DispatcherConfig
   */
  protected DispatcherConfig $config;

  /**
   * Request hookers
   *
   * @var HookInterface[]
   */
  protected array $requestHooks = [];

  /**
   * Magic remap
   */
  protected const MAGIC_REMAP = '_remap';

  /**
   * AbstractFrontRequestDispatcher constructor.
   */
  public function __construct(DispatcherConfig $config)
  {
    $this->config = $config;
    $this->newRequestContext();
    $this->scanPackages();
    $this->scanMappings();
  }

  /**
   * Scan Packages
   *
   * @return void
   */
  abstract protected function scanPackages(): void;

  /**
   * Load RequestMappings
   *
   * @return void
   */
  abstract protected function scanMappings(): void;

  /**
   * Handle Request
   *
   * @param Closure|null $handler
   * @return void
   */
  abstract public function doDispatch(?Closure $handler = NULL): void;

  /**
   * Handle request before
   *
   * @return mixed
   */
  abstract protected function processBefore();

  /**
   * Handle request after
   *
   * @return mixed
   */
  abstract protected function processAfter();

  /**
   * Handle controller returning
   *
   * @param mixed  $response
   * @return mixed
   */
  abstract protected function processReturning($response);

  /**
   * Handle controller exception
   *
   * @param Exception $ex
   * @return mixed
   */
  abstract protected function processException(Exception $ex);

  /**
   * Resolve route.
   *
   * @param string $action
   * @return $this
   * @throws Exception
   */
  protected function resolveRoute(string $action): AbstractFrontRequestDispatcher
  {
    $router = Route::getFacadeRoot();
    $requestMethod = strtolower(Request::server('REQUEST_METHOD'));
    $fullAction = $requestMethod . ($action = '/' . trim($action, " \t\n\r\0\x0B\\/"));

    if (!$this->validateUriChars($action)) {
      throw new BadUrlException('URI has disallowed characters', 400);
    }

    //Match route target
    if (($matches = $this->matchRouteTarget($router, $fullAction)) === NULL) {
      $this->processMatchRouteError($router, $action);
    }
    [$this->target, $argumentsMap] = $matches;

    //Save request dispatcher context
    $this->context->setServiceName($action);
    $this->context->setArguments($argumentsMap);

    //Load request hooks
    $this->loadRequestHook($action);

    return $this;
  }

  /**
   * Resolve request target.
   *
   * @param mixed $target
   * @return array
   * @throws Exception
   */
  protected function resolveRequestTarget($target): array
  {
    //Resolve callable
    if (is_string($target)) {
      if (strpos($target, '@') > 0) {
        $target = explode('@', $target);
      }
      elseif (function_exists($target)) {
        $target = Closure::fromCallable($target);
      }
    }
    if (is_array($target)) {
      [$controller, $method] = array_values($target);
      $callable = $this->getClassCallable($controller, $method, $this->context->getArguments() ?? []);

      $this->context->setClassName($controller);
      $this->context->setMethodName($method);
      $this->context->setClassPath(ClassUtils::getClassFile($controller));
    } elseif ($target instanceof Closure) {
      $callable = $this->getFunctionCallable($target, $this->context->getArguments() ?? []);
    } else {
      throw new RuntimeException('No available service was found', 503);
    }

    return $callable;
  }

  /**
   * Match route target
   *
   * @param RouterMapping $router
   * @param string        $reqAction
   * @param bool          $ignoreMethod
   * @return array|null
   */
  protected function matchRouteTarget(RouterMapping $router, string $reqAction, bool $ignoreMethod = FALSE): ?array
  {
    $requestMapping = $router->getRequestMappings();
    if (!$requestMapping || !$reqAction) {
      return NULL;
    }

    //Fast target location
    if (strpos($reqAction, '{') === FALSE && isset($requestMapping[$reqAction]) && !$ignoreMethod) {
      return [$requestMapping[$reqAction], []];
    }

    foreach ($requestMapping as $key => $target) {
      $reg = $this->processHandlerPathPattern($key);
      if ($ignoreMethod) {
        $reg = substr($reg, strpos($reg, '/'));
      }
      if (preg_match_all('/\{([a-z]+(?:[a-z0-9]+)?)\}/i', $reg, $bindArgs)) {
        $reg = str_replace($bindArgs[0], '([^/]+)', $reg);
      }
      if (preg_match('#^' . $reg . '$#', $reqAction, $matches)) {
        array_shift($matches); //remove first element

        //Validate bind parameters
        $args = array_combine($bindArgs[1], $matches);
        is_array($args) || $args = [];
        $roles = $router->getBindParameterRuleMap();
        if (!$ignoreMethod && !$this->validateRouteBindParameters($roles[$key] ?? [], $args)) {
          throw new RouteBoundParameterException('Illegal bind route parameters ' . $key, 400);
        }

        return [$target, $args];
      }
    }

    return NULL;
  }

  /**
   * Process handle match route  failed
   *
   * @param RouterMapping $router
   * @param string        $action
   * @return void
   * @throws URLNotFoundException
   */
  protected function processMatchRouteError(RouterMapping $router, string $action): void
  {
    if ($this->matchRouteTarget($router, $action, TRUE) !== NULL) {
      throw new URLNotFoundException('The requested method is not supported', 405);
    }

    throw new URLNotFoundException('The requested service does not exists', 404);
  }

  /**
   * Validate route bind parameters
   *
   * @param array  $rules
   * @param array  $args
   * @return bool
   */
  protected function validateRouteBindParameters(array $rules, array $args): bool
  {
    if (empty($rules) || empty($args)) {
      return TRUE;
    }

    foreach ($args as $k => $v) {
      if (isset($rules[$k]) && !preg_match('#^' . $rules[$k] . '$#', $v)) {
        return FALSE;
      }
    }

    return TRUE;
  }

  /**
   * Do the request.
   *
   * @return AbstractFrontRequestDispatcher
   * @throws Exception
   */
  protected function doRequest(): AbstractFrontRequestDispatcher
  {
    //Resolve the request target
    $target = $this->resolveRequestTarget($this->target);

    //Process request target
    $this->response = $this->processRequest($target);

    //Save request dispatcher context
    $this->context->setResponse($this->response);

    //Clean target
    $this->target = NULL;

    return $this;
  }

  /**
   * Process request target
   *
   * @param array $target
   * @return mixed
   */
  protected function processRequest(array $target)
  {
    //Has available before hook?
    $response = $this->processBefore();
    if ($response !== NULL) {
      return $response;
    }

    try {
      //If it's class, dispatch the controller
      if (is_array($target['callable'])) {
        [$controller, $method] = $target['callable'];
        $response = $controller->$method(...$target['args']());
      } else {
        $response = $target['callable'](...$target['args']());
      }
    } catch (Exception $ex) {
      //Has available exception hook?
      $response = $this->processException($ex);
    }

    //If the request target no exception occurs
    if (!isset($ex) && $this->requestHooks) {
      //Has available returning hook?
      $response = $this->processReturning($response);
    }

    //Has available after hook?
    $after = $this->processAfter();
    if ($after !== NULL) {
      $response = $after;
    }

    return $response;
  }

  /**
   * Get http response.
   *
   * @return HttpResponseInterface|null
   * @throws Exception
   */
  protected function getResponse(): ?HttpResponseInterface
  {
    $response = $this->response;
    $this->response = NULL;

    if (is_object($response)) {
      if ($response instanceof HttpResponseInterface) {
        return $response;
      }
      if ($response instanceof JsonSerializable) {
        $response = $response->jsonSerialize();
      }
    }

    switch (gettype($response)) {
      case 'string':
        return ResponseFactory::text($response);
      case 'integer':
      case 'double':
      case 'float':
        return ResponseFactory::text((string)$response);
      case 'array':
        return ResponseFactory::json($response);
      case 'object':
        return ResponseFactory::json(ClassUtils::getAllDeclaredFieldValues($response));
      default:
        return NULL;
    }
  }

  /**
   * Get class callable.
   *
   * @param string $controller
   * @param string $method
   * @param array  $args
   * @return array
   * @throws Exception
   */
  protected function getClassCallable(string $controller, string $method, array $args): array
  {
    if (!class_exists($controller)) {
      throw new ClassNoDefException('No class def found error', 503);
    }

    //Match target method
    if (method_exists($controller, self::MAGIC_REMAP) && is_callable([$controller, self::MAGIC_REMAP])) {
      $args = [];
      $method = self::MAGIC_REMAP;
    } else {
      if (!method_exists($controller, $method)) {
        throw new ClassMethodNoDefException('No method def found error', 503);
      }
      if ($method[0] === '_' || !is_callable([$controller, $method])) {
        throw new ClassMethodNoDefException('Method is not available', 503);
      }
    }

    //Instantiate controller
    $controller = RestContainer::make($controller);
    if ( ! $controller) {
      throw new RuntimeException('Class is not available', 503);
    }

    $this->bindProperties($controller);

    return [
      'callable' => [$controller, $method],
      'args'     => function () use ($controller, $method, $args): array {
        return MethodUtils::getParameters($controller, $method, $args);
      },
    ];
  }

  /**
   * Inject value to object
   *
   * @param object $object
   * @throws ReflectionException
   */
  protected function bindProperties(object $object): void
  {
    $mappings = Property::getFacadeRoot()->getPropertiesMapping();
    $properties = $mappings[get_class($object)];
    if ( ! (isset($properties) && is_array($properties) && $properties)) {
      return;
    }

    $rf = RestContainer::getClassReflector($object);
    foreach ($properties as $property => $resource) {
      if (!$rf->hasProperty($property) || !class_exists($resource)) {
        continue;
      }

      $reflectionProperty = $rf->getProperty($property);
      $reflectionProperty->setAccessible(TRUE);
      $reflectionProperty->setValue($object, RestContainer::make($resource));
    }
  }

  /**
   * Get function callable.
   *
   * @param Closure $closure
   * @param array   $args
   * @return array
   */
  protected function getFunctionCallable(Closure $closure, array $args): array
  {
    return [
      'callable' => $closure,
      'args'     => function () use ($closure, $args): array {
        return FunctionUtils::getParameters($closure, $args);
      }
    ];
  }

  /**
   * Autoloader.
   *
   * @param string $directory
   * @param string $extension
   * @return void
   */
  protected function autoloader(string $directory = '', string $extension = 'inc'): void
  {
    $directory = rtrim($directory, "/ \t\n\r\0\x0B") . '/';

    spl_autoload_register(function ($class) use ($directory, $extension) {
      $file = $directory . strtr($class, ['\\' => '/']) . '.' . $extension;
      is_file($file) && require_once $file;
    }, TRUE, FALSE);
  }

  /**
   * Instantiate dispatcher request context
   *
   * @return void
   */
  protected function newRequestContext(): void
  {
    $this->context = new RequestContext();

    RestContainer::bind(RequestContext::class, $this->context);
  }

  /**
   * Load request hooks
   *
   * @param string $service
   * @throws Exception
   */
  protected function loadRequestHook(string $service): void
  {
    $requestHooks = $this->config->getRequestHook();
    if (empty($requestHooks)) {
      return;
    }

    foreach ($requestHooks as $hook) {
      $target = $hook[HookInterface::CLAZZ];
      if (!class_exists($target) || !is_subclass_of($target, HookInterface::class)) {
        continue;
      }

      $includes = $excludes = FALSE;
      $disallowChars = " \t\n\r\0\x0B\\/";

      //Match include patterns
      $includePathPatterns = $hook[HookInterface::INCLUDE_PATH_PATTERNS];
      foreach ($includePathPatterns as $includePathPattern) {
        if (!is_string($includePathPattern) || !$includePathPattern) {
          continue;
        }
        $includePathPattern = $this->processHandlerPathPattern('/' . trim($includePathPattern, $disallowChars));
        if (preg_match('#^' . $includePathPattern . '$#', $service)) {
          $includes = TRUE;
          break;
        }
      }

      //Match exclude patterns
      $excludePathPatterns = $hook[HookInterface::EXCLUDE_PATH_PATTERNS];
      foreach ($excludePathPatterns as $excludePathPattern) {
        if (!is_string($excludePathPattern) || !$excludePathPattern) {
          continue;
        }
        $excludePathPattern = $this->processHandlerPathPattern('/' . trim($excludePathPattern, $disallowChars));
        if (preg_match('#^' . $excludePathPattern . '$#', $service)) {
          $excludes = TRUE;
          break;
        }
      }

      //Required includes and no excludes
      if ($includes && !$excludes) {
        $this->requestHooks[] = RestContainer::make($target);
      }
    }
  }

  /**
   * Process handler path pattern
   *
   * @param string $pattern
   * @return string
   */
  protected function processHandlerPathPattern(string $pattern): string
  {
    return strtr($pattern, ['/*' => '/(?:[^/]+)', '/**' => '/(?:.*?)']);
  }

  /**
   * Validate request uri chars
   *
   * @param string $uri
   * @return bool
   */
  protected function validateUriChars(string $uri): bool
  {
    return TRUE;
  }
}